iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 4
0
Software Development

每天 Racket 3 分鐘系列 第 11

(lambda (day-10?) (if (day-10?) "媽!我的程式跑不停!— 遞迴與尾遞迴 (tail-recursion)" (void)))

  • 分享至 

  • xImage
  •  

1. 跑不完、跑不完、就是跑不完

還記得前幾天,我們介紹 lambda 時,講到費氏數列嗎?我們所使用的解,算是較為直接的解法,這樣的解法好處是程式邏輯很清楚,數學定義如何,程式就如何。然而問題是,這樣的解法非常沒有效率,因為這樣的遞迴呼叫,會導致同樣的值被呼叫多次,例如 (fib 5) 的呼叫結構,可以參考這張圖:

fib tree

整個遞迴呼叫階層拆開來後,會發現 (fib 2)(fib 3) 被呼叫了許多次,而每次呼叫,都要重新計算。因此,我們在實際測試時,費氏數列大概到 50 就已經到了人類耐性的上限了。不信你試試看囉!

2. 媽!我的費氏數列可以算到 1000 耶!

對於這點,我們有什麼辦法呢?嘿!有人一定會想說,用迴圈解就好了!好極了。通常遞迴可以做的事,稍微動腦筋一下,的確可以用迴圈來改寫。費氏數列這個例子,我們可以這麼想,它其實是一個從 0 開始,兩兩相加的一個數列,因此,我們可以這樣改變問題:

給定初始 a = 0, b = 1,step = n
每回變化為:a = a + b, b = a, n = n - 1
當 n 遞減至 0 時,最終值為 a

我們試著把它轉成一種特殊的寫法:

(define fib-iter
  (lambda (a b step)
    (if (= step 0)
        a
        (fib-iter (+ a b) a (- step 1)))))
(define fib
  (lambda (n)
    (fib-iter 0 1 n)))

還記得我們前面說過,for 是一種語法糖,在程式語言的核心邏輯中,是沒有 for 這種概念的。那要做迴圈時要怎麼辦?我們可以看到上述程式碼的關鍵是 fib-iter 這個函式,它定義了我們剛剛所說的那個概念,而在 if 的反論處,遞迴呼叫了自己。這個遞迴相當的特殊,在程式語言裡面有個專有名詞,叫尾遞迴。尾遞迴不是單指在函式的尾端呼叫自己而已,而是包含了當它呼叫自己時,它的參數已經是個已知值。

遞迴之所以會慢,乃在於函式呼叫時,若是其參數本身也是另一個 expression(表示式),程式語言會先對這個 expression 求值,才傳進去給這個函式,這是一個很合理的邏輯,也是一個在寫遞迴時容易遇到的坑。因此許多語言便發展出對這種不用深入去求值的尾遞迴結構的最佳化處理機制。在執行時期,程式語言會把它轉化成為一種迴圈結構,類似這樣:

a = 0
b = 1

while (n > 0){
    a = a + b
    b = a
    n = n - 1
}

return a

因此,這時候,你真的可以下個 1000 的參數試試,Racket 會很快地算到 1000 然後回傳一個嚇死人的天文數字。

3. 動靜之分

我們在先前有說到,Scheme、Racket 是屬於 lexical scope,又稱 static scope,相對於 Lisp 的 dynamic scope,有些什麼不一樣呢?我們借用一個例子 [1]

(let ([x 2]))
  (let ([f (lambda (y) (* x y))]
    (let ([x 4])
      (f 3))))

這個時候,(f 3) 會是什麼結果呢?126

如果這個程式,在 Emacs Lisp 裡寫,結果會是 12,但在 Racket 裡頭,結果卻是 6。這是為什麼?

早期 Lisp 在發展時,程式語言專家們對於 x 這個自由變數的定義,認為應當是由 lambda 被呼叫的當下,在那個範圍裡去尋找。因此,在執行上述程式時,會找到第三層 letx,所以算出 12。然而,這個機制惹出了相當大的麻煩,使用 Lisp 的程式員在函式呼叫時,會出現意想不到的狀況。於是後來,Common Lisp 拿掉了這個機制,取而代之的是 lexical scope,同樣的,Scheme 一出來時,就選擇了 lexical scope 的做法。因此,你用 Racket 或其他的 Scheme 的實作,都可以得到 6 這個結果。

4. 命名大學問

已經寫到第 11 天,好像有什麼沒講的是吧!我們好像還沒講到命名。在其他語言裡,命名原則通常會很早就說明,可是在 Racket 裡頭,因為所有的操作都是一種 lambda expression,因此我們必須講完 lambda,才能回頭看命名原則。

雖然我在 Racket Reference 沒發現,但在 Scheme 的規格書裡 [2],倒是找到了一段簡單的說明,Racket 與 Scheme 的 id 可以使用英文字母、數字、Unicode 字元,以及各種擴充符號:

! $ % & * + - . / : < = > ? @ ^ _ ~

(幾乎 1 ~ 0 這排全用上了!)

可以使用 Unicode 字元是比較特別的,因為在 Racket 裡頭,可以用 λ 來代替 lambda,鍵盤快速鍵為 ctrl+\,因此要使用中文也是可以的。

其實按照規格書的意思,就是說只要 id 不是一個數字,則基本上都是合法的。換句話說,你可以試試看這樣的宣告:

(define 3a 3)
(define 4d 'd)
(define 嘿 
  (lambda ()
    (displayln "嘿!")))

而 Racket 的命名慣例,還是依照 Scheme 的命名慣例:

  1. 對於有副作用的函式,如賦值,使用 ! 做結尾
  2. 對於轉型的函式,如 numberstring,用 -> 連結
  3. 對於具有判斷作用,回傳 #t#f 的函式,用 ? 做結尾
  4. 對於比較長的,多個字母組成的,用 - 連結
  5. 對於條件判斷類的,用 & 開頭(這點我不熟,或許各位可以參考 rnrs 的說明 [3])

上一篇
(let ([day 9]) (display "Let it be! — Racket 的 Local Binding"))
下一篇
(hash 'day-11 "為你的資料命名 — Racket 的 Hash")
系列文
每天 Racket 3 分鐘17
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言